Docker Layer Cache로 빌드 속도 단축하기

Docker 이미지 빌드 시간 단축은 “Layer를 얼마나 많이 재사용할 수 있느냐”에 달려있다.
Dockerfile 설계만 잘해도 빌드 시간이 절반 이하로 줄고, CI/CD에서 수분 단위의 시간을 아낄 수 있다.

이전 포스트 "Docker Layer"에 언급했듯,
결국 Cache Invalidation을 적게 만드는 것이 중요하다.

다시, Cache가 무효화 되는 조건에 대해 알아보자

Docker는 Dockerfile을 위에서부터 읽으며 레이어 스택을 생성한다. 그리고 “명령어 + 명령어가 의존하는 파일들” 이 이전 빌드와 동일하면 그 레이어를 그대로 재사용한다.

  1. 그래서 명령어 자체가 바뀌거나
  2. 명령어의 입력으로 들어가는 파일이 메타데이터(파일크기, 체크섬 등)가 바뀌거나
  3. 앞선 레이어가 캐시가 무효화되면 뒤로부터 모든 레이어의 캐시가 무효화된다.

Docker Layer(Build) Cache Optimization

Layer 재사용을 위해 가장 먼저 해야 할 일이자 가장 쉬운 일은
Dockerfile 명령어 순서 최적화다.

1. 명령어 순서 최적화하기

# 나쁜 예:
FROM node
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build

COPY . .은 소스코드를 다 이미지로 복제하라는 의미인데,
보통 우리는 소스코드를 변경하고 Docker Image를 빌드한다.
그러니 항상 파일이 변경되어 Layer Cache가 무효화 되고,
그 이후의 레이어들은 매번 다시 실행된다.

package는 자주 바뀌지 않는다.

# 개선된 버전: COPY 분리하고 순서 변경하기
FROM node
WORKDIR /app
COPY package.json yarn.lock ./
RUN npm install
COPY . .
RUN npm run build

이렇게 조금만 바꿔주면, 의존성 설치가 없을 때는 npm install 까지 캐시를 바로 이용할 수 있어서
빌드 시간을 크게 단축할 수 있다.

2. Build Context 최소화하기 .dockerignore

COPY Something

무언가를 COPY해서 이미지 빌드에 포함시킬 때, 빌드에 영향이 없는 것들의 변경 때문에 Layer Cache가 깨질 수 있다. 그러니 .dockerignore를 사용해서, 불필요한 파일을 빌드에 포함시키지 않도록 하자. COPY 시간을 단축하여 빌드 속도도 줄일 수 있으며, 의미없는 캐시 무효화도 방지할 수 있다.

3. Cache Mount : Layer Cache가 깨져도 데이터는 캐싱하기

보통 Layer Cache는 명령어가 같고, 명령어가 참조하는 모든 파일이 이전과 같아야만 재사용이 가능하다.

대신, 특정 폴더를 "캐시 저장소"로 만들면,
빌드가 여러번 실행되도, 그 폴더에 있는 내용은 그대로 남아 재사용 가능한데
이 방법을 Cache Mount라고 부른다.

FROM node:latest
WORKDIR /app
RUN --mount=type=cache,target=/root/.npm npm install

npm install은 /root/.npm을 캐시로 사용하고
레이어 캐시와는 별도로 해당 디렉토리는 빌드 사이에서 계속 유지된다.

따라서 package.json이 변경되어 Layer Cache가 적용되지 않더라도
변경된 패키지만 다운로드하고, 변경되지 않은 패키지는 캐시에서 바로 사용가능하다.
pnpm이라면 pnpm store를 캐시 저장소로 만들면 좋겠다.

Next.js 등 증분 빌드 활용 또한 가능하겠지

Next.jsTurborepo 같은 도구들은 자신만의 .cache 폴더를 두고
증분 빌드를하여 빌드 속도를 빠르게 만들어주곤 한다.

계속 얘기했지만, Dockerfile은 파일 형상이 바뀌면 캐시가 아예 무효화 된다.
pnpm run build 같은 명령어는 그냥 두면 Cache 없이 빌드가 되어 항상 오래걸리게 된다.

.cache 폴더를 캐시 저장소로 만든다면, Dockerfile에서도 증분 빌드를 사용할 수 있다.

COPY package*.json ./
COPY pnpm-lock.yaml ./
RUN --mount=type=cache,target=/root/.pnpm-store pnpm install --store=/root/.pnpm-store

COPY . .

RUN --mount=type=cache,target=/app/.next/cache \
    pnpm run build

Github-Action에서 Build Cache 사용하기 : BuildKit

명령어 순서 최적화, build context 최소화 같은 기본 최적화는 Dockerfile만 잘 구성하면 된다.
하지만 Build Cache(= Layer Cache) 은 “파일 기반 캐시” 이기 때문에
GitHub Actions 같은 ephemeral(일회성) Runner에서는 파일이 존재하지 않아 바로 사용할 수 없다.
따라서 매번 Cold Build가 발생할 수밖에 없다.

이를 해결하려면 BuildKit이 생성하는 캐시를
외부 저장 공간에 영구적으로 보존(push) 하고
빌드 시작 시 다시 불러오는(pull) 방식이 필요하다.

- name: Build and push
  uses: docker/build-push-action@v6
  with:
  push: true
  tags: user/app:latest
  cache-from: type=registry,ref=user/app:buildcache
  cache-to: type=registry,ref=user/app:buildcache,mode=max

Github Action에서는 BuildKit을 사용하여 외부에 저장하는데
BuildKit에는 cache-from, cache-to 옵션으로 구현가능하다.

방법 1 : Github-Action Cache에 저장하기

cache-from: type=gha
cache-to: type=gha,mode=max

이 옵션은 BuildKit이 생성한 레이어 캐시를 GitHub의 캐시 스토리지에 저장한다.
다음 빌드에서는 동일한 키의 캐시를 가져와(BuildKit이 자동으로 매칭),
레이어 캐시를 재사용할 수 있게 된다.

  • 장점
    • 설정이 가장 간단함
    • GitHub Actions 공식 캐시이기 때문에 추가 인프라 필요 없음
    • 소규모 프로젝트나 개인 프로젝트에 적합
  • 단점
    • 리포지토리당 10GB 제한
      • 이미지가 무겁거나 레이어가 많다면 금방 초과됨
      • 초과 시 LRU 방식으로 캐시 자동 제거 → 캐시 안정성이 떨어짐
    • 캐시가 브랜치 별로 구분됨
      • 조직 전체에서 공유 불가
      • 다른 빌드 시스템(CI/CD)과 공유도 불가
      • monorepo일 경우 병목 가능성 있음

방법 2 : 레지스트리에 캐시 저장하기 (Registry Backed Cache)

cache-from: type=registry,ref=yourorg/yourapp:buildcache
cache-to: type=registry,ref=yourorg/yourapp:buildcache,mode=max

두 번째 방식은 Docker 레지스트리를 캐시 저장소로 사용하는 방식이다.
GitHub Actions의 10GB 제한을 완전히 벗어나며. 조직 전체에서 캐시를 공유할 수 있다.

BuildKit은 레지스트리에 캐시를 “하나의 이미지 형태”로 저장한다.

  • 장점
    • 용량 제한 사실상 없음
    • 조직/팀/CI-CD 시스템 전체에서 공유 가능
    • GitHub 외의 다른 CI에서도 동일한 캐시 사용 가능
    • 브랜치별로 캐시를 분리하거나 공유하는 구조도 쉽게 구성 가능
  • 단점
    • 도커 레지스트리 인증이 필요함
    • 캐시 push/pull 때문에 대역폭 사용 증가
    • 설정이 GHA 캐시보다 약간 더 복잡함

하지만 실전에서 Docker Build Cache는 시간이 곧 비용이기 때문에
대부분의 팀/기업은 레지스트리 기반 캐시를 사용한다.


Example : Next.js Dockerfile

위의 내용을 바탕으로 Dockerfile을 작성하면 다음과 같다.
물론 github-action workflow 파일에는 buildkit을 활성화하는 명령어가 필요하다.

FROM node:20-alpine
WORKDIR /app

# 1. 패키지 파일만 먼저 복사 (레이어 캐시 최적화)
COPY package.json pnpm-lock.yaml ./

# pnpm의 store 위치 정의 (캐시 마운트의 대상이 됨)
RUN corepack enable && \
    pnpm config set store-dir /root/.pnpm-store

# 2. 의존성 설치 — Cache Mount로 설치 캐싱
RUN --mount=type=cache,target=/root/.pnpm-store \
    pnpm install --frozen-lockfile


# 3. 소스코드 복사
COPY . .

# 4. Next.js 빌드 — .next/cache를 Cache Mount로 두기
RUN --mount=type=cache,target=.next/cache \
    pnpm build

# 5. 런타임 (단일 스테이지이므로 node_modules + .next 포함됨)
EXPOSE 3000
CMD ["pnpm", "start"]

여기는 근데, 하나의 문제가 있다. 실제 Next서버 동작에 필요하지 않은 것들이 너무 많다는 것이다.
즉, 이미지 사이즈가 너무 크다.
다음 포스트에서는 이미지 사이즈 줄이는 방법에 대해 포스팅을 해보려한다.

References